Skip to content

feat(activity): a boundary for the window you were not watching, and one that lasts - #27

Merged
HarryCordewener merged 16 commits into
feat/find-chordsfrom
feat/window-activity-boundary
Aug 11, 2026
Merged

feat(activity): a boundary for the window you were not watching, and one that lasts#27
HarryCordewener merged 16 commits into
feat/find-chordsfrom
feat/window-activity-boundary

Conversation

@HarryCordewener

Copy link
Copy Markdown
Member

Second of a three-PR stack — based on feat/find-chords (#26), review that one first.
Design: docs/superpowers/specs/2026-08-11-pane-search-and-activity-design.md, part 3.

The boundary generalises

Today the client marks where you left off only when you tab away from the terminal. Look away from
a window instead — switch tabs, switch characters, scroll a pane back — and it counts the lines on
a badge and marks nothing. The badge said 3 and nothing on screen said which 3.

A window now accrues from the first line that lands while it is not Workspace.IsCaughtUp — visible
and at its live tail. That is deliberately the same rule the unread badge answers to, so a badge
showing a count always has a bar under it saying where the count begins.

It is recorded forwards, which the terminal boundary beside it cannot be: a terminal reports
focus-in and this client cannot observe focus-out at all, so that one is reconstructed from the input
before the last. Where a window has both, the older wins — a reader who typed after lines landed
in a window they could not see has a terminal boundary at the end of that buffer, claiming they missed
nothing.

Two words, not one bar with two meanings: AWAY is about the reader, NEW is about the window. The
window bar carries no duration, because its boundary is made when a line arrives rather than at a
moment the reader was part of — a span on it would be timing the output.

The bar lasts

Retirement gains a third conjunct: a dwell floor, F7 ▸ ACTIVITY, default 30 s, 0 restoring the old
behaviour exactly. Two were not enough — on a shallow absence the pane is already at its live tail, so
the next keystroke took the bar a second or two after it appeared. Time and not keystrokes because
that is the unit the complaint was in. It is a floor, not a timer: nothing fires on its own, so an
untouched client keeps its bar. New defaulted field, so no schema bump and no migration.

Two things worth a reviewer's eye

  • The reveal is only for arriving at a window. ⌃End is an explicit "take me to the live tail",
    and scrolling somewhere else in answer to it moves the reader off the place they just asked for.
    CtrlEndGoesBackToFollowingTheLiveTail caught exactly that. The pane is re-pinned after the insert
    instead, because a whole-buffer re-feed leaves the offset a frame behind.
  • Nothing accrues before the constructor finishes (_watching). Until the workspace is laid out,
    "not visible" means "no pane built yet" — and RestorePreviousSession pours a previous run's lines
    through the same seam into windows that already sit under a restore bar.

Verification

dotnet build -c Release SharpMUTerm.slnx clean and warning-free; five suites green (2,664 tests).
New activity-bar snapshot view rendered and looked at.

The snapshot recipe in CLAUDE.md is also corrected: it said dotnet build SharpMUTerm.slnx, which
builds Debug, while --no-build runs Release. A new view then renders byte-identical to the
default frame from a stale binary, which is indistinguishable from a feature that does not work.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 118a24b4-72ae-49aa-8f29-b4ec29c98e92

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

HarryCordewener and others added 11 commits August 11, 2026 15:59
…dwell floor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Beside the terminal-absence bar rather than replacing it: AWAY is about the
reader, NEW is about the window, and a reader who meets both in one client
should be able to tell them apart without counting the lines.

No duration on this one. The terminal bar's span is measured from the last
input before the reader vanished — approximate, but an instant they were part
of. This boundary is made when a line lands in a window nobody is watching, so
a span on it would be timing the output rather than the absence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Plain text in, matching lines out: nothing here knows about panes, markup or
windows, which is what keeps it in Core and what makes a match mean what it
looks like. A colour tag mid-word must not split a match, and nobody should be
able to search for #ff0000 and find every red line.

Five decisions, each pinned: case ignored in both modes (HistorySearch already
is, and (?-i) is the way back, which is why there is no third toggle); one
match per line, the first, with offsets so a row can show why it is listed; an
empty query matches nothing, unlike history, because a pane buffer is thousands
of lines and "everything, oldest first" is the pane you are already looking at;
an invalid pattern is a state, because a regex is unparseable most of the time
it is being typed; and a match timeout, because this runs on the UI thread on
every keystroke over every line of every window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…erminal

A window accrues a boundary from the first line that lands while it is not
caught up — visible *and* at its live tail, the rule the unread badge already
answers to — so a badge showing a count always has a bar under it saying where
the count begins. The badge said 3 and nothing said which 3; that was the report.

Recorded forwards, unlike the terminal boundary beside it, which has to be
reconstructed from the input before the last because focus-out is unobservable.
Where both exist the older wins: a reader who typed after lines landed in a
window they could not see has a terminal boundary at the end of that buffer,
saying they missed nothing, and the window's own boundary knows better.

The reveal is only for *arriving* at a window. Coming back to the tail by ⌃End
is an explicit "take me to the live tail", and a client that answered it by
scrolling somewhere else would move the reader off the place they just asked
for — CtrlEndGoesBackToFollowingTheLiveTail is the test that says so. The pane
is re-pinned after the insert instead, because a whole-buffer re-feed leaves
the offset a frame behind and the newest line would blink off the screen.

Nothing accrues before the constructor finishes: until the workspace is laid
out "not visible" means "no pane built yet", and the restore replay pours a
previous session through the same seam into windows already under a restore bar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
MarkupText.Plain is what a markup line actually puts on the screen, and PaneLine
holds it beside the markup, computed once at append. On demand would restrip
every line of every window on every keystroke of a query, and an incremental
surface is the whole point.

Plain rather than markup because a match has to mean what it looks like: a world
may change colour mid-word, and matching the markup would find neither half —
the same defect a URL split by a colour change has, one layer down — while a
query for #ff0000 must not find every red line.

Chrome rows carry no plain text, so a search cannot find its own boundary bars.

Plain and VisibleLength are pinned against each other over a table of inputs:
they share a protect-then-strip shape, and a divergence would put a match's
offsets in a different coordinate system from the width renderers measure with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Two conditions were not enough. On a shallow absence the pane is already at its
live tail when the reader returns, so the very next keystroke retired the bar a
second or two after it appeared — the bar going before it could be read.

The third is a floor in time, because that is the unit the complaint was in: a
raised input count would be an hour on a quiet character and three seconds on a
busy one. It is a floor and not a timer — nothing fires on its own, so the bar
goes on the first of the existing checks after the floor has passed, and a
client sitting untouched keeps its bar.

F7 ▸ ACTIVITY, default 30, and 0 restores the previous behaviour exactly. New
defaulted field, so no schema bump and no migration. Measured off the app's
existing TimeProvider, so the tests move the clock instead of racing it.

AwayDividerTests takes the floor at zero: those tests are about where the bar is
drawn and what removes it, and several turn on "one keystroke is all it takes".
Each suite asserts one rule rather than both at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…t says

The HistorySearchPrompt/HistorySurface split verbatim: the rules and the
wording are exactly the part a headless test can pin. ⌥E and ⌥A both re-search
from the top, because each changes what the list is and a pointer kept at row 12
of a different result set points at nothing the reader chose.

The header states the bound it searched — '12 found · 4,812 lines held' — because
⌃F sees the pane buffer and not a session's whole history, and a reader who
cannot find an old line should be able to see why rather than concluding the
search is broken. The window column is drawn only when every window is searched;
with one window it would be the same word on every row.

The footer names only keys that work, pinned by a test that presses every one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Adds the `activity-bar` view — three lines land in the main window while Chat
is in front of it, and picking main back lands on the NEW bar with those three
under it. Separate from `away` because the two are separate facts with separate
wording, and this is the one that happens many times an hour.

Also corrects the snapshot recipe, which cost this change real time: it said
`dotnet build SharpMUTerm.slnx`, which builds *Debug*, while `--no-build` runs
the *Release* output. The render then comes from a binary predating your change
and a new view comes out byte-identical to the default frame — indistinguishable
from a feature that does not work. The trap hides whenever you have just run the
suites, because those refresh Release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
The chord, the scope and the landing. ⏎ activates the window the line is really
in — through Activate, the one activation path, so the pane, the tab and the
session move together rather than a pane being scrolled behind the reader's
back — and marks the line with a bar, fourth of the boundary bars, which points
rather than repaints: the line is worth having because it is the game's own text
in the game's own colours.

Two things measured rather than chosen. ⌥⇧G is *not* bound: kitty writes it as
CSI 103;4u, a kitty-keyboard-protocol sequence AnsiInputParser drops, so it
could never arrive — ⌥⇧1's story one letter over. And Escape does not clear the
bar: a claimed Escape does not set _escapeAt, and TryAltEnter pairs an unclaimed
one with a following Enter to make Alt+⏎, so binding it would break the newline
chord for as long as a bar was on screen.

⌥G removes the bar *before* re-running the search. The bar is itself a row, so a
search run around it returns indices in a buffer about to lose one and every hit
below it lands a row early — which is what the test caught.

Two kinds of inserted chrome now, so the index bookkeeping every buffer mark
depends on is one pair of methods (InsertChromeRow/RemoveChromeRow) rather than
written out at each site: the freeze point, the pending boundary, the activity
bar and the search bar all move together or none of them do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Four views, because four things about ⌃F are only visible in a frame: a plain
query with its hits marked, the same query read as a pattern (the header is the
only place either state is said — compose/compose-literal's reasoning), the
widened scope where the window column appears, and what ⏎ leaves behind. The
last is over a split, so the pane is narrower than the terminal: that is the
geometry that catches a landing scrolled to the wrong row.

The demo scene now loads with _watching off. It pours a spawn window's whole
history in before the first frame, so every line counted as missed and any frame
that later made such a window visible carried an activity bar reporting the
client's own setup as news — which is exactly what the first cut of the landed
frame showed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
@HarryCordewener
HarryCordewener force-pushed the feat/window-activity-boundary branch from 037ae39 to 7659a87 Compare August 11, 2026 21:00
HarryCordewener and others added 5 commits August 11, 2026 16:01
The reported defect was "highlight colours don't seem to actually work".
They work alone — `Highlight_RecoloursMatchedRegion` has always passed —
and they were destroyed by the rule's *own* rewrite. `Process` applied the
highlight to the matched region and then, four lines later, replaced the
whole line with `StyledLine.FromText(text, TextStyle.Default)`: no colour,
no attributes, no left rule.

That combination is not exotic, it is what a channel rule looks like —
route it, tidy it to `» $1`, colour it — and it is the shape of the demo
configuration's own headline rule. The F2 screen badged such a rule `H` and
painted both swatches, so the client promised a highlight it then threw
away, and the only way to find one was to discover that deleting the
rewrite brought it back.

The rewrite now runs first and the highlight covers the whole of what it
produced. It cannot cover the match's own offsets, because after a rewrite
those address a string that no longer exists; the rewritten text is the
rule's product in its entirety, so colouring all of it is the only reading
that means anything. Without a rewrite nothing moves: the highlight covers
the match and only the match, as it always has.

A *later* rule's rewrite still replaces an earlier rule's highlighted text,
and that is correct rather than the same bug one rule over — those
characters are gone. Pinned, so the ordering fix is not later generalised
into re-colouring text the first rule never saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…ly to a spawn

"Triggers should not be restricted to only be able to go to spawns."
They were, and the restriction was one method deep: `Workspace.RouteSpawn`
was the only destination resolver a matched rule had, and it computes
`SpawnWindowId(sessionKey, target)` and registers a new `WindowKind.Spawn`
window when nothing answers to that id. There is no branch in it that can
reach a window that already exists under any other name, so a route naming
a window on the screen opened a second one beside it wearing the same
label, and the one the user was looking at stayed empty.

`Workspace.RouteLine` is the resolver now: a window the target already
names wins, and creating a capture pane is what happens when nothing does.
`FindRouteTarget` is the same lookup without the side effects, so the shell
can still tell "this line opened a pane" from "this line went to one that
was already there" without routing twice.

What a target may reach is deliberately narrower than "any window with that
title": this session's own windows, the windows nobody owns, and another
character's *main* window — one alt's channel collected into the pane you
actually read. It is not another session's spawn or auxiliary window, because
two characters running one capture rule get a pane each and a bare title
lookup would collapse them back into one, which is the exact defect
`SpawnWindowId` was given an owner to fix. Only a placed window is a
destination: appending to a window no pane holds is indistinguishable from
the rule not firing. And finding never creates, which is the property that
bounds a capture-derived target — that arm can only ever land in a window
the user already has.

Two consequences in the shell. `OwnerLabel` is stamped on this session's own
capture panes only; writing our name onto a window somebody else owns would
rename their pane after whoever last routed a line into it. And the F2
`route` field now suggests the workspace's own windows as well as the other
rules' targets — while the list was the rules' targets alone, the one place
a user reads what a route may say could not name any window they had open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Review catch. The title lookup already required a *placed* window, and the
renamed-spawn fallback beside it did not — so a spawn window whose pane the user
closed could be returned as a destination and the channel written into a buffer
nothing draws. That is reachable rather than theoretical: the registry outlives
the layout, and a restored workspace registers windows a saved layout no longer
places. It also contradicted RouteLine's own doc comment, which says a closed
window is passed over and the line goes somewhere visible.

Both halves, because guarding only the fallback moves the same defect one call
deeper: RouteSpawn returned a registered-but-unplaced window untouched, since it
only placed one it had just created. It now places on the way past whether or not
the window is new — which is what lets FindRouteTarget decline a closed window and
fall through, reopening the pane under the same id with its history in it.

The regression test was checked against the unfixed code and fails there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
feat(search): ⌃F searches the output across the panes
fix(triggers): route to a window that already exists, and keep a highlight through a rewrite
@HarryCordewener
HarryCordewener merged commit 361771e into feat/find-chords Aug 11, 2026
1 check passed
@HarryCordewener
HarryCordewener deleted the feat/window-activity-boundary branch August 11, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant